如何自定义TCP通信协议 您所在的位置:网站首页 java 自定义协议 如何自定义TCP通信协议

如何自定义TCP通信协议

2024-06-19 01:43| 来源: 网络整理| 查看: 265

       物联网行业智能硬件之间的通信、异构系统之间的对接、中间件的研发、以及各种即时聊天软件等,都会涉及自定义协议。

为了满足不同的业务场景的需要, 应用层之间通信需要实现各种各样的网络协议。以异构系统的对接为例。在早期,我们使用 Web Service 来解决异构系统的对接,后来我们逐渐使用 MQ、RPC 等方式来实现异构系统的通信和整合。

Web Service 是使用 SOAP 协议通过 HTTP 进行传输。MQ 有很多常用的消息队列协议,例如 AMQP、MQTT、STOMP 等,而新兴的消息队列,如 Kafka 和 ZeroMQ,它们并没有严格遵循 MQ 规范,而是基于TCP/IP 协议自行封装了一套协议,并通过 TCP 进行传输。另外,像 Dubbo 这样的 RPC 框架,本身支持多种协议。其自身的 Dubbo 协议也是阿里巴巴自己实现的应用层协议,并通过 TCP 进行传输。

因此,设计好一款合理的、可扩展的自定义协议,可以打通不同的异构系统,亦或者可以作为一款 RPC 框架的基石。今天我将手把手带你设计一个高效、可扩展、易维护的自定义通信协议,以及如何使用 Netty 实现该协议的 TCP 服务端。

为什么需要自定义通信协议?

我们在开发一款工业自动化的智能硬件时,通常需要一台上位机(一般是一款桌面端应用程序)来控制不同的硬件设备。上位机可以独立存在,也可以由 Web 后台发送指令到上位机,再通过上位机来控制智能硬件,以此来完成业务上的操作。

从上位机到 Web 后台之间的通信,可能是由一个 TCP 长连接(也可能是 WebSocket 长连接)来进行维护,而上位机到各个硬件设备之间也可能通过长连接来维护,当然也可以是串口、MQTT、CoAP 等协议,这主要取决于所连接的设备。从 Web 后台到上位机再到智能硬件,假如都使用了 TCP 长连接,那么后两者甚者可以使用 TCP 透传。

无论是 TCP 的长连接,还是 WebSocket 的长连接,本质都是基于 TCP 的连接,为此我们需要使用 Socket 编程,通俗地说可以认为它是对 TCP 协议的具体实现。此外,我们所熟知的中间件、网络游戏、智能硬件、金融等领域也都会涉及 Socket 相关的编程。在使用 Socket 编程时,我们经常会听到别人提起“自定义协议”。事实上,目前已经有了很多标准的协议,那我们为何还需要“自定义”呢?

我们先从下面这张 OSI 七层模型的图开始,快速回顾一下网络通信的面貌。

TCP/IP 协议将 OSI 七层模型进行了简化,变成四层模型。

在 TCP/IP 协议中从应用层到网络接口层,每一层传输的数据包都会包含两部分内容:一部分是协议所要用到的首部,另一部分是从上一层传过来的数据。下图展示了 TCP/IP 包的全貌。

我们所熟知的各种网络应用程序都是在应用层上使用的,TCP/IP 协议的应用层为我们提供了多种常见的应用层协议,例如 HTTP、SSH、Telnet、FTP 等。正是有了这些协议,各种网络应用程序才可以为我们服务。

另外,应用层也支持给我们的程序“量身”制定协议,也就是支持“自定义协议”。当常用的应用层协议不满足我们的应用开发时,例如扩展性不够、安全性不足、不能针对特定领域、无法追求极致的性能等,就需要“自定义协议”。

如何设计自定义通信协议?

TCP 是一种流模式的协议,在实现自定义协议时,我们会遇到诸如以下的问题:

1.应用程序如何知道业务数据是全部接收完毕的,如何解决拆包和粘包问题?

2.如何实现请求/响应机制?

3.如何解决超时问题和实际应用的通信需求?

4.如何定义消息指令或报文类型?

……

自定义通信协议

为了解决上述的问题,首先我们介绍一种比较通用的 TCP 通信协议,其协议结构如下:

+--------------+---------------+------------+---------------+-----------+-

| 魔数(4)| version(1)|序列化方式(1)|command(1)|SerialNo(2)|数据长度(4)|数据(n)   |

+--------------+---------------+------------+---------------+-----------+-

下面我们对这个协议中的内容展开介绍。

魔数:4 个字节,为了防止该 TCP 端口被意外调用。我们在收到报文后取前 4 个字节与魔数比对,如果不相同则直接拒绝并关闭连接。魔数可以随意定义,比如采用 20200803 作为魔数,它的 16 进制是 0x1343d63。

版本号:1 个字节,仅表示协议的版本号,便于协议升级时使用。

序列化方式:1 个字节,表示如何将 Java 对象转化为二进制数据,以及如何反序列化。

指令:1 个字节,也可以叫报文类型,表示该消息的意图,如登录、心跳、升级,以及不同的业务指令等。最多可支持 256 种指令(-127 到 127)。

SerialNo:2 个字节,表示整个任务的 id 或者任务的流水号,便于进行追踪。最多支持 2^16 位(-32,768 到 32,767)。

数据长度:4 个字节,表示该字段后数据部分的长度。类似于 HTTP 协议的报文头中的 Content-Length  这个字段。最多支持 2^32 位。

数据:具体的数据内容。

根据上述设计的通信协议,定义一个报文类 Message,它代表通信协议的报文,如下所示:

public abstract class Message { private MessageHeader messageHeader; private T messageBody; public T getMessageBody() { return messageBody;

}

}

Message 参考 TCP 协议,将其抽象成由 Header 和 Payload 组成(即首部和数据块)。其中,报文的 Header 部分共 9 个字节,包含魔数、版本号、序列化方式、指令、SerialNo,结构如下:

+--------------+---------------+------------+---------------+-----------+

| 魔数(4)       | version(1)    |序列化方式(1)      | command(1)           |SerialNo(2)|

+--------------+---------------+------------+---------------+-----------+

因此可以定义一个如下的 Header 类:

public class MessageHeader {

    private int magicNumber; // 魔数

    private int version = 1; // 版本号,当前协议的版本号为 1

private int serializeMethod; // 序列化方式,默认使用 json

    private int command;      // 消息的指令

    private long serialNo;    // 任务的流水号

}

每个 Payload 都是报文的具体内容,即协议体。它可以是一个字符串也可以是一个复杂的对象,因此我们定义一个空接口用于表示 Payload,所有的 Payload 都需要实现该接口:

public abstract class MessageBody { }

考虑到需要预留和扩展性,以避免在将来报文经常性地被修改,可以给 Payload 增加一个预留的属性 extra ,它是一个 Map 类型。因此,再定义一个基类的 BasePayload,我们也可以在 Header 中额外定义一个字段作为一个预留字段。

按照上述的设计,该协议的报文头/首部只有 9 个字节,相比于 HTTP 协议的报文头还是少了很多,极大地精简了传输内容。这也是为什么后端的 RPC 框架通常会采用自定义 TCP 协议进行通信。

Packet 的一次完整旅行

介绍完自定义通信协议后,我们来看看 Packet 在一个 TCP 服务中是怎样经历一次完整的旅行的。

(1)定义指令集

在业务系统中,我们通常需要定义很多个指令,一个指令对应一个 Packet。Header 的 command 字段用来区分不同的指令。

在 Packet 的 Header 中,command 定义了 1 个字节,表示它支持  256 种指令。所以,我们可以定义一个最多包含 256 个指令的指令集 Commands,其定义方式如下:

/* * 指令集 */ public interface Command { /** * 心跳包 */ final Byte HEART_BEAT = 0; /** * 登录请求 */ final Byte LOGIN_REQUEST = 1; /** * 登录响应 */ final Byte LOGIN_RESPONSE = 2; /** * 消息请求 */ final Byte MESSAGE_REQUEST = 3; /** * 消息响应 */

final Byte MESSAGE_RESPONSE = 4;

}

当然,如果觉得 256 个指令不够,修改协议 Header 中 command 的字节数即可。

下面以心跳的 Packet 为例,首先定义一个 HeartBeatPacket:

public class HeartBeatPacket extends Packet { private String msg = "ping-pong"; public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } @Override public Byte getCommand() { return HEART_BEAT; } }

心跳包一般是由 TCP 客户端发起,经由 TCP 服务端接收后,进行响应并返回给客户端。它像心跳一样每隔固定时间发送一次,以此来告诉服务端,这个客户端还活着。

(2) 定义序列化方式

心跳包的内容很小,可以使用 JSON 进行解析。但是对于图片、视频、日志文件等比较大的内容,可能需要使用 Java 自带的序列化方式,或由 Kryo、Hessian、FST、Protobuf 等框架实现对象的序列化和反序列化。

因此,我们定义一个序列化方式的常量列表,代码如下:

* 定义序列化算法 */ public interface SerializeAlgorithm { /** * json序列化标识 */

byte json = 1;

    

    byte binary = 2;

    byte fst = 3;

}

上面的代码表示目前只支持这些序列化方式,后续可以不断添加新的序列化方式。

再定义一个序列化接口,每种序列化方式需要一个相应的实现,代码如下:

* * Serializer,用来指定序列化算法,用于序列化对象 */ public interface Serializer { /** * @return 序列化算法 */ byte getSerializerAlgorithm(); /** * 将对象序列化成二进制 * */ byte[] serialize(Object object); /** * 将二进制反序列化为对象 */ T deSerialize(Class clazz, byte[] bytes); }

由于,存在多个序列化方式,可以考虑设计一个序列化的工厂类SerializerMap,通过工厂类来获取指令所需要的序列化实现。

private static final Map serializerMap;

serializerMap = new HashMap(); Serializer serializer = new JsonSerializer(); serializerMap.put(serializer.getSerializerAlgorithm(), serializer);

(3)定义 Packet 的工厂类

最初,我们将 Packet 抽象成 Header 和 Payload 两部分,因此 Packet 的生成也包含了两部分的生成。

前面,我们定义了一些客户端、服务端的指令,也知道不同的指令对应不同的 Packet。因此,可以通过指令来生成对应的 Payload。Header 中本身就包含了 command,唯一需要注意的就是序列化方式 serializeMethod,不同的 command 对应唯一的 serializeMethod。

下面是 Packet 的工厂类,用于生成 Payload 和 Header:

* * 编解码对象 */ public class PacketCodeC { /** * 魔数 */ public static final int MAGIC_NUMBER = 0x88888888; public static PacketCodeC instance = new PacketCodeC(); /** * 采用单例模式 */ public static PacketCodeC getInstance(){ return instance; } private static final Map


【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有